5.05. ASP.NET
ASP.NET
C# позволяет писать не только десктопные и мультиплатформенные приложения, но и веб-приложения. Для этого направления есть целая технология ASP.NET.
Давайте погрузимся в веб-разработку.
Часть 1. Введение в веб-разработку на платформе .NET
Веб-разработка в экосистеме .NET — это последовательная эволюция архитектурных подходов, обусловленная изменением требований к приложениям: от монолитных, серверных, stateful систем к распределённым, stateless, API-ориентированным сервисам. Эта эволюция отражена в линейке технологий Microsoft: ASP.NET Web Forms → ASP.NET MVC → ASP.NET Web API → ASP.NET Core.
Web Forms (2002) стремился перенести модель событийного программирования Windows Forms в веб — с её PostBack’ами, ViewState и серверными контролами. Это позволяло разработчикам, привыкшим к desktop-разработке, быстро создавать интерактивные веб-страницы, но ценой чёрного ящика: сложная модель состояния, неявная генерация HTML, трудности в тестировании и масштабировании. Архитектура была page-centric: логика жила внутри страницы, а не в переиспользуемых компонентах.
ASP.NET MVC (2009) стал ответом на рост популярности фреймворков вроде Ruby on Rails и Django. Он ввёл явное разделение ответственности по шаблону Model-View-Controller, обеспечил полный контроль над HTML, упростил unit-тестирование и способствовал созданию чистых HTTP-интерфейсов. MVC не заменил Web Forms — он предложил альтернативную модель, ориентированную на разработчиков, мыслящих в терминах HTTP и REST.
Параллельно выросла потребность в интерфейсах для клиентских приложений (SPA, мобильные приложения). ASP.NET Web API (2012) был извлечён из MVC как отдельный стек для построения HTTP-based API — лёгких, сериализуемых, контрактных сервисов, возвращающих JSON или XML. Хотя Web API использовал тот же DI, маршрутизацию и фильтры, что и MVC, его семантика была иной: здесь не было View — только модели, контроллеры (ApiController) и сериализаторы.
Настоящий перелом произошёл с появлением ASP.NET Core (2016). Это не улучшенная версия ASP.NET — это переписанная с нуля платформа, не имеющая прямой зависимости от System.Web.dll, совместимой с .NET Framework. ASP.NET Core — кроссплатформенный, высокопроизводительный, модульный и open-source фреймворк, объединивший MVC, Web API и Razor Pages в единую модель. Он построен на новых принципах:
- Явная конфигурация вместо магии (нет глобальных статических классов вроде
HttpContext.Current), - Композиция через middleware, а не наследование и события,
- Встроенный DI и конфигурация как первоклассные граждане,
- Контракт хостинга, позволяющий запускать приложение в любом окружении: от IIS до Docker-контейнера на Linux.
Ключевое понимание: в ASP.NET Core нет «веб-приложения» как отдельной сущности. Есть хост, который управляет жизненным циклом приложения, и pipeline, обрабатывающий входящие запросы. Всё остальное — добавляемые компоненты.
Часть 2. ASP.NET Core: архитектура и хостинг
Ядро: Kestrel
Kestrel — это встроенный, кроссплатформенный, асинхронный веб-сервер на базе System.IO.Pipelines и System.Threading.Channels. Он является обязательной частью любого ASP.NET Core-приложения, даже если оно развёрнуто за IIS или Nginx.
Kestrel не позиционируется как полноценный edge-сервер для публичного доступа (хотя с .NET 7+ его безопасность и производительность позволяют использовать его напрямую в некоторых сценариях). Его основная задача — обеспечить единый контракт выполнения между приложением и хост-окружением. Независимо от того, запущено ли приложение в облаке, на локальной машине или в контейнере, Kestrel предоставляет один и тот же интерфейс: он принимает TCP-соединения, парсит HTTP/1.1 или HTTP/2, формирует объект HttpContext и передаёт его в pipeline.
Важно: Kestrel всегда работает как самостоятельный процесс. Даже при развёртывании в IIS он запускается внутри w3wp.exe, но не встраивается в него как модуль — он живёт как отдельный managed-поток, управляемый хостом.
Варианты развёртывания
ASP.NET Core поддерживает три основные модели хостинга, различающиеся по способу запуска и управлению процессом.
-
Standalone (self-hosted)
Приложение компилируется как исполняемый файл (.exeна Windows, без расширения на Linux), который сам запускает Kestrel. Это реализуется черезWebApplication.CreateBuilder().Build().Run(). Такой процесс может быть запущен напрямую из командной строки, через systemd (Linux), launchd (macOS) или Task Scheduler (Windows). Для production-развёртывания обычно используется reverse proxy (Nginx, Apache, HAProxy), который:- принимает внешние запросы,
- обрабатывает TLS/SSL,
- балансирует нагрузку,
- защищает от DDoS и медленных клиентов,
- передаёт запросы Kestrel по локальному сокету или HTTP.
Это стандартный подход для Linux-хостинга и облачных сред (Azure App Service в Linux-режиме, AWS ECS, Kubernetes).
-
Windows Service / Linux Daemon
Для long-running background-приложений (например, внутренние API, интеграционные шлюзы), не требующих веб-интерфейса, ASP.NET Core позволяет зарегистрировать приложение как системную службу.- На Windows используется
WindowsServiceLifetime, и приложение устанавливается черезsc create. - На Linux — через systemd unit-файл с типом
Type=notifyиExecStart=/path/to/app.
Такой подход даёт автоматический перезапуск при падении, управление зависимостями («запускать после сети»), журналирование через системные журналы (journald, Event Log).
Kestrel в этом режиме может быть отключён (webBuilder.UseKestrel() => webBuilder.ConfigureKestrel(options => options.ListenLocalhost(0))или вообще не вызываться), если приложение не слушает HTTP-порты.
- На Windows используется
-
Интеграция с IIS (In-Process и Out-of-Process)
IIS остаётся важной платформой для enterprise-развёртываний на Windows. ASP.NET Core поддерживает два режима работы под IIS:
-
Out-of-Process (режим по умолчанию до .NET Core 3.0)
IIS выступает как reverse proxy. Модуль ASP.NET Core Module (ANCM) перехватывает запрос, запускает отдельный процессdotnet.exe, в котором работает Kestrel, и перенаправляет трафик через named pipe. Процесс управляется IIS: запускается при первом запросе, останавливается при простое.
Преимущества: изоляция, совместимость со старыми IIS-модулями.
Недостатки: накладные расходы на межпроцессное взаимодействие. -
In-Process (начиная с .NET Core 3.0)
ANCM загружает .NET Core Runtime непосредственно в рабочий процесс IIS (w3wp.exe). Kestrel не используется — вместо него ASP.NET Core использует IIS HTTP Server, который напрямую взаимодействует с HTTP.sys через IIS.
Это даёт до 2–3× прирост производительности за счёт устранения IPC, но:- приложение должно быть
framework-dependent(не self-contained), - требуется совместимость с модулями IIS (например, URL Rewrite работает, но некоторые legacy-модули — нет),
- все приложения в Application Pool должны использовать одну и ту же версию .NET.
- приложение должно быть
Как это работает на уровне Windows?
Когда IIS принимает HTTP-запрос, он не обрабатывает его напрямую. За это отвечает Windows Process Activation Service (WAS) — ядро хостинга, появившееся в IIS 7. WAS управляет жизненным циклом Application Pools и рабочих процессов.
- World Wide Web Publishing Service (WWW Service) — это компонент WAS, отвечающий именно за HTTP/HTTPS. Он читает конфигурацию из
applicationHost.config(глобальный файл настройки IIS, обычно в%windir%\System32\inetsrv\config), создаёт Application Pools и привязывает их к сайтам. - Каждый Application Pool — это изолированное окружение, в котором работает один или несколько сайтов. У Pool’а есть свои настройки: версия .NET, режим pipeline (Integrated/Classic), учётная запись, limits (CPU, memory, requests).
- Рабочий процесс — это
w3wp.exe(Worker Process). Каждый Pool может иметь один или несколько таких процессов (Web Garden).w3wp.exeзагружает модули IIS, включаяaspnetcorev2_inprocess.dll(для In-Process) илиaspnetcorev2_outofprocess.dll(для Out-of-Process). svchost.exe— это не часть IIS, а общий хост для Windows-сервисов. WAS и WWW Service работают внутриsvchost.exe(можно увидеть в Process Explorer:svchost.exe -k iissvcs). Это важно понимать при диагностике: сбой в WAS может повлиять на все сайты сервера.
Типы развёртывания: self-contained vs framework-dependent
-
Framework-dependent deployment (FDD)
Приложение компилируется только в IL-код и метаданные. Для запуска требуется предустановленный .NET runtime той же (или совместимой) версии. Это уменьшает размер дистрибутива, упрощает обновление runtime, но требует контроля над окружением. Используется в IIS In-Process, в shared hosting и при централизованном управлении. -
Self-contained deployment (SCD)
Приложение поставляется вместе со всей необходимой частью .NET runtime. Это позволяет запускать его на «чистой» машине, использовать специфическую версию (например, с патчем), изолироваться от глобальных обновлений. Размер увеличивается на ~100–150 МБ, но зато нет внешних зависимостей. Используется в standalone-режиме, в контейнерах, в offline-средах.
Модели хостинга: shared, on-premise, cloud
-
Shared hosting — устаревшая модель, где множество сайтов делят один Application Pool и ресурсы сервера. В ASP.NET Core почти не используется: нарушает изоляцию, мешает настройке, несовместима с SCD.
-
On-premise — развёртывание в собственном дата-центре. Здесь возможны все варианты: IIS (In/Out-of-Process), Windows Service с reverse-proxy, bare-metal Kestrel. Требует управления инфраструктурой, но даёт полный контроль.
-
Cloud — абстрагированная среда (Azure App Service, AWS Elastic Beanstalk, Google Cloud Run). Платформа управляет масштабированием, балансировкой, обновлениями. В Azure App Service, например, Linux-вариант использует standalone + Docker + Nginx, Windows-вариант — IIS In-Process. Cloud-провайдеры предоставляют встроенные health-checks, logging, monitoring — но требуют адаптации приложения (например, stateless-дизайн, externalised config).
Часть 3. Конвейер обработки HTTP-запроса
HttpContext: контекст жизни запроса
Каждый HTTP-запрос, поступающий в Kestrel, инкапсулируется в объект Microsoft.AspNetCore.Http.HttpContext. Он — единственный источник истины о текущем запросе и ответе. Именно через него middleware и компоненты приложения взаимодействуют с клиентом.
HttpContext не является простым DTO. Это активный объект, связывающий:
- входящий запрос (
HttpRequest: метод, URL, заголовки, тело, куки), - исходящий ответ (
HttpResponse: статус, заголовки, тело), - состояние аутентификации (
User,AuthenticationFeature), - сессию (
Items— dictionary для передачи данных между middleware), - DI-контейнер текущего request scope (
RequestServices), - вспомогательные сервисы (
Features— расширяемая коллекция интерфейсов, например,IHttpConnectionFeature,IHttpRequestIdentifierFeature).
Важно: HttpContext создаётся один раз на запрос и уничтожается после отправки ответа. Его нельзя хранить в статических полях, кэшировать или передавать в фоновые потоки — это приведёт к неопределённому поведению или утечкам памяти. Для асинхронной обработки следует использовать RequestDelegate или IHostedService с явной передачей данных.
Middleware: компоненты конвейера
Middleware (промежуточное ПО) — это функция, которая получает HttpContext, может выполнить произвольную логику до и после передачи управления следующему middleware, и, при необходимости, прервать цепочку.
Формально middleware — это делегат вида RequestDelegate, но на практике реализуется как класс с методом Invoke или InvokeAsync, принимающим HttpContext и RequestDelegate next. Порядок регистрации middleware в Program.cs определяет порядок их выполнения — это не деталь реализации, а архитектурное решение.
Три ключевых метода расширения для построения pipeline:
-
app.Use(Func<HttpContext, Func<Task>, Task> middleware)
Регистрирует не терминальный middleware. Он всегда вызываетnext(), передавая управление дальше, и может выполнять код как до, так и после этого вызова. Пример — логирование:app.Use(async (context, next) =>
{
var start = Stopwatch.GetTimestamp();
await next(); // выполнение следующих middleware и конечной точки
var duration = Stopwatch.GetElapsedTime(start);
logger.LogRequest(context.Request.Path, duration);
});Такой middleware формирует «обёртку» (wrapper), подобную try-finally.
-
app.Run(Func<HttpContext, Task> middleware)
Регистрирует терминальный middleware. Он не вызываетnext()— вместо этого сам формирует ответ и завершает pipeline. Пример — fallback-обработчик:app.Run(context =>
{
context.Response.StatusCode = 404;
return context.Response.WriteAsync("Not Found");
});После
Runничего не выполняется. ПоэтомуRunобычно ставится в самый конец. -
app.Map(string pathMatch, Action<IApplicationBuilder> configuration)
Условно-ветвящий middleware. Если путь запроса начинается сpathMatch, создаётся вложенный pipeline, в котором выполняются middleware изconfiguration. Это позволяет изолировать логику для отдельных подсистем (например,/apivs/admin).
Пример:app.Map("/health", healthApp =>
{
healthApp.Run(context => context.Response.WriteAsync("OK"));
});Внутри
healthAppможно использоватьUse,Run,Map— это полноценныйIApplicationBuilder.
Существуют также MapWhen (ветвление по условию, например, context.Request.Headers.ContainsKey("X-Internal")) и UseWhen (условное подключение middleware без ветвления всего pipeline).
Принцип работы конвейера
Pipeline — это линейная цепочка вызовов, реализованная через композицию делегатов. При вызове app.Build() фреймворк рекурсивно оборачивает каждый middleware в замыкание, где next указывает на следующий в цепочке.
Логически выполнение выглядит так:
[Client]
↓ HTTP Request
[Kestrel] → создаёт HttpContext
↓
[Middleware 1] → Before next()
↓
[Middleware 2] → Before next()
↓
...
↓
[Middleware N] → Before next()
↓
[Endpoint] → выполнение контроллера / Razor Page / делегата
↑
[Middleware N] → After next()
↑
...
↑
[Middleware 2] → After next()
↑
[Middleware 1] → After next()
↑
[Kestrel] → отправка ответа
↑
[Client]
Если какой-либо middleware не вызывает next(), цепочка прерывается, и управление возвращается вверх по стеку — только те middleware, которые уже вызвали next(), выполнят свой «after»-код. Это позволяет реализовывать short-circuiting: например, middleware аутентификации может сразу вернуть 401, не передавая запрос дальше.
Семантика порядка middleware
Порядок критичен. Вот рекомендуемая последовательность (с обоснованием):
-
UseExceptionHandler/UseDeveloperExceptionPage
Самый первый — чтобы перехватывать исключения на всех уровнях. -
UseHttpsRedirection
Раннее перенаправление с HTTP на HTTPS — до любой бизнес-логики. -
UseStaticFiles
Обслуживаниеwwwroot— если запрос совпадает с файлом, pipeline завершается здесь (этоRunвнутри). -
UseRouting
Не обрабатывает запрос, а только определяет endpoint. После него становятся доступны данные маршрутизации (context.GetEndpoint()), но endpoint ещё не вызван. -
UseAuthentication
Определяетcontext.User— должен быть до авторизации и бизнес-логики. -
UseAuthorization
Проверяет права на основеUserи политик — после аутентификации, до вызова endpoint. -
UseSession
ТребуетUserдля привязки сессии — после авторизации. -
UseEndpoints/MapRazorPages/MapControllers
Вызывает endpoint (контроллер, страницу и т.д.). Это терминальный middleware для основного потока. -
Run(fallback)
Обработка 404 — в самом конце.
Нарушение этого порядка ведёт к ошибкам: например, если UseAuthorization поставить до UseAuthentication, User будет null, и все проверки провалятся.
Middleware vs Filter
Часто возникает путаница между middleware и фильтрами (MVC Filters, Razor Pages Filters).
-
Middleware работает на уровне всего приложения, вне зависимости от того, какой endpoint вызван. Он получает «голый»
HttpContext. Подходит для кросс-функциональных задач: логирование, CORS, сжатие, обработка ошибок. -
Фильтры привязаны к конкретной модели разработки (MVC или Razor Pages). Они получают уже связанный с endpoint контекст:
ActionExecutingContext,PageHandlerExecutingContextи т.д. Это даёт доступ к:- параметрам действия,
- результату выполнения,
ModelState,- DI-зависимостям контроллера.
Фильтры выполняются внутри endpoint-обработчика, после маршрутизации, но до/после вызова метода.
Таким образом, middleware — внешняя оболочка, фильтры — внутренняя инструментовка. Они дополняют друг друга.
Сравнение с OWIN
OWIN (Open Web Interface for .NET) — спецификация 2010-х годов, определявшая минимальный контракт между веб-сервером и приложением: Func<IDictionary<string, object>, Task>. ASP.NET Core изначально планировался как реализация OWIN, но в итоге от него отошёл.
Почему? OWIN слишком низкоуровнев:
- Передача данных через
IDictionary— нет типизации, легко ошибиться в именах ключей. - Нет поддержки DI, конфигурации, логирования «из коробки».
- Middleware не могли зависеть от порядка инициализации.
ASP.NET Core сохранил идею композиции, но заменил словарь на строго типизированный HttpContext, а OWIN-совместимость вынес в отдельный пакет (Microsoft.AspNetCore.Owin), который оборачивает ASP.NET Core в OWIN-интерфейс — но не наоборот.
Сегодня OWIN используется только для совместимости с legacy-библиотеками (например, некоторыми OAuth-провайдерами). Для нового кода применяется нативный pipeline.
Часть 4. Модели разработки веб-приложений
Model-View-Controller (MVC)
MVC — это архитектурный шаблон, адаптированный для веб. В ASP.NET Core он реализован как полноценная модель разработки с чётким разделением:
-
Model — не только классы данных (DTO, domain entities), но и логика предметной области: валидация, преобразования, взаимодействие с репозиториями. Model отвечает на вопрос «что?» — какие данные нужны и как они связаны.
-
View — строго типизированный шаблон (
.cshtml), отвечающий за визуальное представление. View знает только о своей Model (через@model), не содержит бизнес-логики и не обращается к сервисам напрямую. Её задача — преобразовать данные в HTML. Важно: View не управляет состоянием — она реактивна. -
Controller — координатор. Он принимает HTTP-запрос, извлекает данные (из query, body, route), вызывает сервисы для подготовки Model, выбирает View и передаёт ей данные. Controller отвечает на вопрос «как?» — как обработать запрос, но не «почему?» — это прерогатива сервисов.
Жизненный цикл вызова в MVC:
- Middleware
UseRouting()сопоставляет URL с шаблоном маршрута (например,{controller=Home}/{action=Index}/{id?}). - Middleware
UseEndpoints()создаёт экземпляр контроллера через DI. - Model Binding заполняет параметры действия (action method) из источников:
[FromRoute]— из сегментов URL (/product/123→id = 123),[FromQuery]— из строки запроса (?page=2),[FromBody]— из тела (JSON/XML),[FromForm]— из multipart/form-data.
- Выполняются Action Filters (например,
[Authorize],[ValidateModel]). - Вызывается метод контроллера (action). Он может:
- вернуть
View()с Model (рендеринг страницы), - вернуть
Json(),Ok(),CreatedAtAction()(Web API-стиль), - перенаправить (
RedirectToAction()).
- вернуть
- Если возвращён
ViewResult, запускается View Engine (Razor), который компилирует.cshtmlв C#-код, выполняет его и генерирует HTML. - Выполняются Result Filters (например, кэширование ответа).
Когда применять MVC?
— Когда нужно гибкое управление маршрутизацией (RESTful API + HTML-страницы в одном приложении),
— Когда View и Controller разрабатываются разными командами (фронтенд vs бэкенд),
— Когда требуется сложная композиция UI (View Components, частичные представления),
— В enterprise-приложениях с многоуровневой архитектурой (Presentation → Application → Domain → Infrastructure).
Razor Pages
Razor Pages — это page-centric модель, появившаяся в ASP.NET Core 2.0 как ответ на потребность в более простой альтернативе MVC для CRUD-сценариев и внутренних инструментов (админки, панели управления).
-
Page Model — класс с расширением
.cshtml.cs, унаследованный отPageModel. Он объединяет логику обработки и данные для отображения. Свойства помечаются атрибутами привязки ([BindProperty]), методы —OnGet(),OnPost(),OnPostAsync(). -
Page — файл
.cshtml, который:- обязательно начинается с
@page, - привязан к Page Model через
@modelили по соглашению имён, - может содержать обработчики в
@functions, но это не рекомендуется.
- обязательно начинается с
Ключевые отличия от MVC:
| Критерий | MVC | Razor Pages |
|---|---|---|
| Единица разработки | Контроллер + View | Page Model + Page |
| Маршрутизация | Глобальные шаблоны или атрибуты на контроллере | Автоматическая по пути файла (например, /Pages/Admin/Users/Index.cshtml → /Admin/Users/Index) |
| Состояние | Нет встроенного механизма | ModelState, TempData, ViewData работают так же, но проще управлять в рамках одной страницы |
| Сложность | Выше (разделение на 3 части) | Ниже (всё в одном месте) |
| Тестирование | Контроллеры легко тестируются изолированно | Page Model тестируется как обычный класс, но с зависимостью от PageContext |
Жизненный цикл вызова в Razor Pages:
UseRouting()сопоставляет URL с файлом страницы (по соглашению).UseEndpoints()создаёт экземпляр Page Model через DI.- Выполняются Page Filters (аналог Action Filters).
- Вызывается метод-обработчик (
OnGet(),OnPost()и т.д.).- Привязка модели происходит автоматически для свойств с
[BindProperty](по умолчанию — только для POST). - Можно использовать
[BindProperty(SupportsGet = true)]для GET-параметров.
- Привязка модели происходит автоматически для свойств с
- Если метод возвращает
Page(), запускается Razor Engine. - Выполняются Result Filters.
Когда применять Razor Pages?
— Для внутренних инструментов (админки, отчёты), где важна скорость разработки,
— Когда страница автономна (редко переиспользуется логика между страницами),
— В образовательных проектах — проще объяснить «страница = файл + код».
Важно: Razor Pages не заменяет MVC. Это альтернатива для других сценариев. Проект может сочетать обе модели: Razor Pages для админки, MVC — для публичного API.
Web API
Web API — это подход к построению HTTP-based интерфейсов, ориентированных на программное потребление (SPA, мобильные приложения, микросервисы). В ASP.NET Core Web API не выделен в отдельный фреймворк — он интегрирован в MVC.
Ключевые признаки Web API-контроллера:
- Наследуется от
ControllerBase(не отController, чтобы избежать View-функциональности), - Помечен атрибутом
[ApiController], - Методы возвращают
IActionResult(или напрямую объект, который сериализуется в JSON/XML).
Атрибут [ApiController] включает важные соглашения по умолчанию:
- Автоматическая привязка из тела для сложных типов (без
[FromBody]), - Автоматический ответ 400 при
!ModelState.IsValid, - Требование явных атрибутов маршрутизации (
[Route],[HttpGet]), - Интерпретация параметров как обязательных, если нет
?или значения по умолчанию.
Два стиля построения API:
-
REST-like (ресурс-ориентированный)
Основан на концепции ресурса (например,/api/users/123). Операции выражаются через HTTP-методы:GET /users→ список,GET /users/123→ получение,POST /users→ создание,PUT /users/123→ полное обновление,PATCH /users/123→ частичное обновление,DELETE /users/123→ удаление.
Преимущества: предсказуемость, кэшируемость (GET), совместимость с инструментами (Swagger, Postman).
Недостатки: сложно выразить сложные операции («перевести деньги»), требует HATEOAS для навигации.
-
RPC-like (операция-ориентированный)
URL выражает действие:/api/transactions/transfer,/api/reports/generate. Тело запроса содержит все параметры.
Преимущества: естественно для бизнес-операций, легко версионировать.
Недостатки: нарушает семантику HTTP, труднее кэшировать, менее стандартизировано.
На практике большинство API — гибрид: ресурсы для CRUD, RPC — для сложных операций.
Когда применять Web API?
— При создании бэкенда для SPA (React, Angular, Vue),
— При построении микросервисов,
— Для интеграции с внешними системами (B2B API),
— В сценариях, где клиент управляет UI (а сервер — только данными и логикой).
ASP.NET Web Forms: архитектурное наследие
Web Forms (2002) — это модель, имитирующая событийное программирование Windows Forms в вебе. Её ключевые концепции существенно отличаются от современных подходов и требуют отдельного объяснения — не для использования, а для сопровождения legacy-систем.
-
Страница (Page) — это класс, унаследованный от
System.Web.UI.Page. Каждый.aspx-файл компилируется в такой класс при первом запросе. -
Жизненный цикл страницы — строго определённая последовательность событий, через которые проходит Page при обработке запроса:
PreInit → Init → InitComplete → LoadViewState → LoadPostData → PreLoad → Load → LoadComplete → PreRender → PreRenderComplete → SaveStateComplete → Render → Unload.
Разработчик может подписаться на любое событие и выполнять код. Например, инициализация динамических контролов — вInit, привязка данных — вLoad, финальные правки — вPreRender. -
PostBack — механизм отправки формы на ту же страницу. При нажатии кнопки (
<asp:Button>) генерируется JavaScript-вызов__doPostBack(), который отправляет POST-запрос с данными формы и__VIEWSTATE.
Это позволяет сохранять состояние элементов управления без перезагрузки всей страницы (в связке сUpdatePanel— частичный PostBack через AJAX). -
ViewState — механизм сериализации состояния контролов в скрытое поле
__VIEWSTATE. При PostBack сервер десериализует его и восстанавливает состояние (значения TextBox, выбор в DropDownList и т.д.). Это скрывает stateless-природу HTTP, но увеличивает объём трафика и уязвим к подделке (требуетmachineKeyдля защиты). -
Дерево элементов управления (Control Tree) — иерархическая структура, где каждый элемент (Label, Button, GridView) — это объект с собственным жизненным циклом. При рендеринге каждый контрол вызывает
Render(), формируя HTML.
Почему Web Forms устарел?
— Сложность тестирования (сильная связность с HttpContext),
— Неэффективность (избыточный ViewState, тяжёлый HTML),
— Отсутствие контроля над генерируемым HTML,
— Несовместимость с современными фронтенд-фреймворками,
— Зависимость от IIS и Windows.
Однако многие enterprise-системы (1C, SAP, внутренние ERP) до сих пор используют Web Forms — поэтому понимание его архитектуры необходимо для миграции или интеграции.
Blazor: SPA на .NET
Blazor — фреймворк для создания одностраничных приложений (SPA) с использованием C# и Razor, без JavaScript. Он существует в двух режимах хостинга:
-
Blazor Server
- UI-логика (обработчики событий, компоненты) выполняется на сервере,
- Взаимодействие с браузером — через SignalR-соединение (WebSockets или long polling),
- Состояние компонентов хранится на сервере (в памяти),
- Преимущества: высокая производительность сервера, доступ к полному .NET API,
- Недостатки: задержки при высокой latency, масштабирование требует sticky sessions, уязвимость к потере соединения.
-
Blazor WebAssembly (WASM)
- Вся логика компилируется в WebAssembly и выполняется в браузере,
- Для доступа к данным — HTTP-запросы к API (обычно ASP.NET Core Web API),
- Преимущества: offline-возможности, масштабирование (статический хостинг),
- Недостатки: начальная загрузка (2–5 МБ), ограничения WASM (нет доступа к файловой системе, ограниченный .NET API), сложность отладки.
Компонентная модель Blazor:
— Компонент — это класс с расширением .razor, содержащий HTML-разметку и C#-логику,
— Взаимодействие через параметры ([Parameter]) и события (EventCallback),
— Жизненный цикл: OnInitialized, OnParametersSet, OnAfterRender,
— Состояние управляется через StateHasChanged() или INotifyPropertyChanged.
Когда применять Blazor?
— Blazor Server — для внутренних инструментов с контролируемой сетью (офисные приложения),
— Blazor WASM — для public-приложений, где важна клиентская производительность после загрузки,
— В командах, где нет экспертизы по JavaScript, но есть сильные .NET-разработчики.
WebAssembly (WASM) — бинарный формат для выполнения кода в браузере. Он не заменяет JavaScript, а дополняет его: WASM-модуль может вызывать JS и наоборот. В .NET WASM-файл содержит IL-код, который интерпретируется Mono runtime, скомпилированным в WASM.
Часть 5. Маршрутизация
Концепция endpoint
Endpoint — это не URL, а логическая точка входа в приложение: метод контроллера, Razor Page, делегат RequestDelegate, gRPC-сервис. Каждый endpoint имеет:
- Шаблон маршрута (например,
api/[controller]/[action]), - Метаданные (атрибуты:
[Authorize],[ResponseCache]), - Делегат обработки (
RequestDelegate).
Маршрутизация в ASP.NET Core разделяется на два этапа:
-
Регистрация endpoint’ов — происходит при запуске приложения (в
Program.cs).
Например:endpoints.MapControllers(); // регистрирует все ApiController’ы
endpoints.MapRazorPages(); // регистрирует все Razor Pages
endpoints.MapGet("/ping", () => "OK"); // регистрирует делегат -
Сопоставление запроса с endpoint’ом — происходит на каждом запросе в middleware
UseRouting().
Оно не вызывает endpoint, а только определяет, какой endpoint соответствует запросу, и сохраняет его вHttpContext.GetEndpoint().
Только после этого middleware UseEndpoints() (или Map*) вызывает делегат endpoint’а.
Такое разделение позволяет middleware, зарегистрированным между UseRouting() и UseEndpoints(), получать доступ к метаданным endpoint’а (например, проверить, требует ли он авторизации), не запуская его логику.
Соглашения vs атрибуты
ASP.NET Core поддерживает два способа определения маршрутов:
-
Маршрутизация на основе соглашений
Глобальные шаблоны, задаваемые при регистрации endpoint’ов. Пример:endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"
);controller,action,id— параметры маршрута,Home,Index— значения по умолчанию,?— параметр необязательный.
Преимущества: единообразие, централизованное управление.
Недостатки: сложно выразить сложные сценарии (версионирование, многоязычные URL). -
Маршрутизация на основе атрибутов
Маршруты задаются непосредственно на контроллерах и методах:[ApiController]
[Route("api/v{version:apiVersion}/[controller]")] // шаблон на уровне контроллера
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")] // шаблон на уровне метода
public IActionResult Get(int id) { ... }
}[controller],[action]— токены, заменяющиеся на имя класса/метода,{id:int}— параметр с ограничением (только целые числа),v{version:apiVersion}— параметр с кастомным ограничением (ApiVersionConstraint).
Преимущества: точный контроль, поддержка REST, версионирование.
Недостатки: дублирование, сложность аудита всех маршрутов.
В реальных проектах часто используется гибрид: соглашения для базовой структуры, атрибуты — для специфичных случаев.
Параметры маршрутов и ограничения
Параметры маршрута — это сегменты URL, заключённые в фигурные скобки: {parameter}. Они могут иметь:
- Значения по умолчанию:
{id=0}или через= defaultValueв шаблоне, - Необязательность:
{id?}— сегмент может отсутствовать, - Ограничения (constraints) — встроенные или кастомные правила валидации.
Встроенные ограничения:
int,long,guid,datetime— типы,min(1),max(100),range(1,100)— числовые диапазоны,alpha,regex(...),length(5,10)— строковые правила,bool—true/false,apiVersion— для библиотеки Microsoft.AspNetCore.Mvc.Versioning.
Пример с несколькими ограничениями:
[HttpGet("report/{year:int:min(2000):max(2050)}/{month:range(1,12)}")]
public IActionResult GetReport(int year, int month) { ... }
Кастомные ограничения реализуются через IRouteConstraint и регистрируются в DI:
builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("slug", typeof(SlugConstraint));
});
public class SlugConstraint : IRouteConstraint
{
public bool Match(HttpContext httpContext, IRouter route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
var value = values[routeKey]?.ToString();
return value != null && Regex.IsMatch(value, @"^[a-z0-9\-]+$");
}
}
Route-to-code binding
Процесс превращения URL в вызов метода включает:
-
Сопоставление шаблона
URL/api/products/123сопоставляется с шаблономapi/[controller]/{id}→controller = "Products",id = "123". -
Поиск контроллера
По соглашению ищется классProductsController(суффиксControllerдобавляется автоматически). -
Поиск метода (action)
- Сопоставление по HTTP-методу (
[HttpGet]), - Сопоставление по имени (если шаблон содержит
[action], иначе — по соглашению:Get,Post,GetByIdи т.д.), - Сопоставление по параметрам (количество и имена должны совпадать с параметрами метода или route/query).
- Сопоставление по HTTP-методу (
-
Привязка модели (Model Binding)
Значения параметров маршрута (id = "123") передаются в параметры метода. Если тип не совпадает (например,int id), вызываетсяTypeConverterилиModelBinder.
Если ни один endpoint не найден — возвращается 404. Если найдено несколько — возникает неоднозначность (ambiguous match), и приложение не запустится (ошибка на этапе регистрации).
Расширяемость маршрутизации
ASP.NET Core позволяет создавать кастомные endpoint’ы и маршруты:
-
Кастомные endpoint builders
Например,MapHealthChecks()— это extension method, который регистрирует endpoint с делегатом проверки здоровья. -
Кастомные маршрутизаторы (IRouter)
РеализацияIRouterдаёт полный контроль над логикой сопоставления. Используется редко (например, для legacy-совместимости). -
Dynamic endpoint registration
Можно регистрировать endpoint’ы динамически при обработке запроса (например, для CMS с URL из БД):app.MapDynamicControllerRoute<CustomTransformer>("/content/{**slug}");где
CustomTransformerреализуетIDynamicEndpointTransformer.
Важно: динамическая маршрутизация снижает производительность (сопоставление на каждый запрос), поэтому для статических маршрутов предпочтительна статическая регистрация при старте приложения.
Часть 6. Шаблонизация и представления
Razor Engine: от шаблона к коду
Razor — это язык шаблонов, позволяющий встраивать C#-код в HTML-подобную разметку. Его ключевая особенность — контекстно-зависимый парсинг: Razor различает HTML-контекст и код-контекст на основе символов (@, {, }, ;), что делает синтаксис лаконичным.
Но важно понимать: Razor — это не интерпретатор. Это компилятор, который на этапе сборки (или при первом запросе) преобразует .cshtml-файл в C#-класс, унаследованный от RazorPage<TModel>. Например, для Index.cshtml генерируется класс Index, содержащий метод ExecuteAsync(), в котором:
- HTML-литералы → вызовы
WriteLiteral(), @model.Name→ вызовыWrite(Model.Name),@{ ... }→ встраивание C#-блока.
Этот класс компилируется в сборку (обычно в App.dll при публикации с RazorCompileOnPublish=true), что обеспечивает:
- Типовую безопасность — ошибки в шаблоне обнаруживаются на этапе компиляции,
- Высокую производительность — нет парсинга шаблона при каждом запросе,
- Поддержку отладки — можно ставить точки останова в
.cshtml.
В режиме разработки (Development) Razor поддерживает динамическую компиляцию: при изменении .cshtml файл перекомпилируется «на лету», без перезапуска приложения.
Безопасность шаблонов: auto-encoding и XSS
По умолчанию Razor автоматически экранирует весь вывод через @:
<p>@userInput</p> <!-- userInput = "<script>alert(1)</script>" -->
<!-- Результат: <script>alert(1)</script> -->
Это предотвращает XSS-атаки. Экранирование применяется к string, object, HtmlString (если не помечен как доверенный).
Для вывода недоверенного HTML используется Html.Raw():
@Html.Raw(Model.TrustedHtml) <!-- Только если HTML прошёл санитизацию! -->
Но это опасная операция — её следует применять только к данным, прошедшим строгую валидацию и очистку (например, через библиотеку HtmlSanitizer).
Композиция UI: уровни переиспользования
ASP.NET Core предоставляет иерархию механизмов для повторного использования разметки — от простых фрагментов до полноценных компонентов.
-
Layout (макет)
Файл_Layout.cshtmlзадаёт общую структуру страницы:<!DOCTYPE html>
<html>
<head>...</head>
<body>
<header>...</header>
<main>
@RenderBody() <!-- Содержимое конкретной страницы -->
</main>
<footer>...</footer>
@RenderSection("Scripts", required: false) <!-- Опциональные скрипты -->
</body>
</html>Страница указывает layout через:
@{
Layout = "_Layout";
}или глобально в
_ViewStart.cshtml(см. ниже). -
_ViewStart.cshtml
Специальный файл, выполняемый перед каждой View или Page. Обычно используется для задания общегоLayout:@{
Layout = "_Layout";
}Располагается в папке
Views/(для MVC) илиPages/(для Razor Pages). Иерархия:_ViewStartв подпапке переопределяет родительский. -
Partial Views (частичные представления)
Фрагменты разметки без логики (_ProductCard.cshtml), включаемые в другие страницы:<partial name="_ProductCard" model="Model.Product" />
<!-- Или через HTML-хелпер: @Html.Partial("_ProductCard", Model.Product) -->Подходят для простых, статичных блоков (карточки, формы). Не имеют собственного Page Model.
-
View Components
Это полноправные компоненты с логикой и представлением. Состоят из:- Класса, унаследованного от
ViewComponent, с методомInvokeAsync()илиInvoke(), - Представления
Default.cshtmlв папкеViews/Shared/Components/ComponentName/.
Пример:
public class PriorityListViewComponent : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(int maxPriority)
{
var items = await GetItemsAsync(maxPriority);
return View(items);
}
}Вызов в шаблоне:
@await Component.InvokeAsync("PriorityList", new { maxPriority = 3 })
<!-- Или через Tag Helper: <vc:priority-list max-priority="3" /> -->Преимущества перед Partial Views:
— Поддержка DI (можно принимать сервисы в конструкторе),
— Асинхронность,
— Тестирование как обычного класса. - Класса, унаследованного от
Tag Helpers: серверная логика в HTML
Tag Helpers — это компоненты, расширяющие HTML-теги декларативной серверной логикой. В отличие от HTML-хелперов (например, @Html.ActionLink()), они не нарушают читаемость разметки.
Примеры встроенных Tag Helper’ов:
-
<a asp-controller="Home" asp-action="Index">
Генерирует<a href="/Home/Index">, автоматически учитывая маршруты и параметры. -
<form asp-action="Submit" method="post">Добавляетaction="/Controller/Submit",AntiForgeryToken, управляет методом. -
<input asp-for="Email" />Генерирует<input name="Email" id="Email" value="@Model.Email" />, с поддержкойDataAnnotations(валидация, типы). -
<environment include="Development">
Условный рендеринг для окружений.
Создание кастомного Tag Helper’а:
-
Класс, унаследованный от
TagHelper, с атрибутом[HtmlTargetElement]:[HtmlTargetElement("email", Attributes = "address")]
public class EmailTagHelper : TagHelper
{
public string Address { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "a";
output.Attributes.SetAttribute("href", $"mailto:{Address}");
output.Content.SetContent(Address);
}
} -
Регистрация в
_ViewImports.cshtml:@addTagHelper *, MyApp -
Использование:
<email address="support@example.com" />
<!-- Результат: <a href="mailto:support@example.com">support@example.com</a> -->
Преимущества Tag Helpers:
- Сохраняют HTML-подобный синтаксис,
- Поддерживаются инструментами (IntelliSense, валидация в IDE),
- Изолированы — не влияют на другие теги.
Кэширование шаблонов
Для повышения производительности ASP.NET Core кэширует:
- Скомпилированные типы страниц (в памяти),
- Результаты рендеринга (если используется
[ResponseCache]илиIMemoryCache).
Кэш компиляции сбрасывается при изменении .cshtml (в режиме разработки) или при перезапуске приложения. В production-сборках шаблоны компилируются статически, и кэш не требуется.
Для динамического контента (например, новостная лента) применяется фрагментное кэширование через Tag Helper <cache>:
<cache expires-after="@TimeSpan.FromMinutes(10)">
<partial name="_NewsFeed" model="Model.News" />
</cache>
Кэш учитывает параметры (например, vary-by-user), чтобы разные пользователи видели своё содержимое.
Часть 7. Конфигурация и управление параметрами
Единая модель IConfiguration
Корень всей конфигурации — интерфейс Microsoft.Extensions.Configuration.IConfiguration. Он предоставляет:
- Иерархический доступ через ключи с разделителем
:(например,"Database:ConnectionString"), - Единообразное API для чтения значений (
GetSection,GetValue<T>), - Ленивую загрузку — значения считываются из источника только при первом обращении.
IConfiguration строится как стек поставщиков (providers). Каждый поставщик — это реализация IConfigurationProvider, которая загружает данные из конкретного источника. При чтении значения фреймворк проходит по стеку снизу вверх и возвращает первое найденное значение — это позволяет переопределять настройки более приоритетными источниками.
Поставщики конфигурации и их приоритеты
Стандартные поставщики (в порядке регистрации, т.е. увеличения приоритета):
-
Файлы (
appsettings.json,appsettings.{Environment}.json)
Основной источник. Поддерживает вложенность через JSON-объекты:{
"Database": {
"ConnectionString": "Server=...",
"Timeout": 30
}
}Файл окружения (например,
appsettings.Production.json) переопределяет значения из базового файла. -
Переменные среды
Ключи преобразуются:__заменяется на:(например,Database__ConnectionString). Это позволяет задавать настройки в Docker, Kubernetes, Azure App Settings без изменения кода. -
Аргументы командной строки
Формат:--key=valueили/key value. Используется для переопределения в CI/CD или при локальном запуске. -
Пользовательские секреты (User Secrets)
Только вDevelopment. Хранит чувствительные данные (пароли, ключи) в зашифрованном файле вне репозитория (%APPDATA%\Microsoft\UserSecrets\<id>\secrets.json). Активируется черезAddUserSecrets<Program>(). -
Azure Key Vault, Consul, etcd
Через сторонние пакеты (Azure.Extensions.AspNetCore.Configuration.Secrets). Для production-секретов.
Порядок регистрации в Program.cs определяет приоритет:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args);
Здесь CommandLine имеет наивысший приоритет — его значения переопределят всё остальное.
Options pattern: типизированный доступ к конфигурации
Прямое использование IConfiguration["key"] не рекомендуется: это нарушает типовую безопасность и усложняет тестирование. Вместо этого применяется Options pattern — привязка конфигурации к POCO-классам.
-
Определение класса настроек:
public class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
public int Timeout { get; set; } = 30;
} -
Регистрация в DI:
builder.Services.Configure<DatabaseOptions>(
builder.Configuration.GetSection("Database")
); -
Инъекция в компоненты:
public class MyService
{
private readonly DatabaseOptions _options;
public MyService(IOptions<DatabaseOptions> options)
{
_options = options.Value; // получение значения
}
}
Три интерфейса для работы с опциями:
-
IOptions<T>— синглтон-значение, загружаемое при старте приложения. Не реагирует на изменения конфигурации. -
IOptionsSnapshot<T>— создаётся на каждый запрос (scoped). Подходит для сценариев, где настройки могут меняться между запросами (например, multi-tenant). -
IOptionsMonitor<T>— позволяет подписаться на изменения конфигурации:_monitor.OnChange(options =>
{
logger.LogInformation("Database timeout changed to {Timeout}", options.Timeout);
});Требует поддержки reload’а от поставщика (например,
AddJsonFile(..., reloadOnChange: true)).
Валидация конфигурации
Options pattern поддерживает валидацию через Data Annotations и кастомные правила:
-
Атрибуты валидации:
public class DatabaseOptions
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 300)]
public int Timeout { get; set; } = 30;
} -
Регистрация с валидацией:
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database")
.ValidateDataAnnotations()
.Validate(options => options.Timeout > 0, "Timeout must be positive."); -
Проверка при старте:
var app = builder.Build();
app.ValidateOptions(); // выбросит исключение при невалидных настройках
Это гарантирует, что приложение не запустится с некорректной конфигурацией.
Создание кастомных поставщиков
Для специфичных источников (например, конфигурация из базы данных, gRPC-сервиса) можно реализовать свой поставщик.
-
Реализация
IConfigurationProvider:public class DatabaseConfigurationProvider : ConfigurationProvider
{
private readonly IDbConnection _connection;
public DatabaseConfigurationProvider(IDbConnection connection)
{
_connection = connection;
}
public override void Load()
{
Data = _connection.Query("SELECT Key, Value FROM Config")
.ToDictionary(x => x.Key, x => x.Value);
}
} -
Реализация
IConfigurationSource:public class DatabaseConfigurationSource : IConfigurationSource
{
private readonly string _connectionString;
public DatabaseConfigurationSource(string connectionString)
{
_connectionString = connectionString;
}
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new DatabaseConfigurationProvider(
new SqlConnection(_connectionString)
);
}
} -
Метод расширения для удобства:
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddDatabaseConfiguration(
this IConfigurationBuilder builder, string connectionString)
{
return builder.Add(new DatabaseConfigurationSource(connectionString));
}
} -
Регистрация:
builder.Configuration.AddDatabaseConfiguration(
builder.Configuration["DbConfig:ConnectionString"]
);
Кастомные поставщики интегрируются в общий стек и участвуют в переопределении значений наравне со встроенными.
Иерархия конфигурации и привязка
Конфигурация поддерживает сложные структуры:
-
Массивы:
"AllowedHosts": [ "localhost", "example.com" ]Привязка к
List<string> AllowedHosts. -
Вложенные объекты:
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
}
}Привязка к
LoggingOptionsс вложеннымDictionary<string, string> LogLevel. -
Секции как отдельные объекты:
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection("Email:Smtp")
);
Привязка выполняется через Microsoft.Extensions.Configuration.Binder, который использует рефлексию и поддерживает:
- Простые типы (
int,bool,string), - Коллекции (
List<T>,Dictionary<K,V>), - Комплексные объекты (рекурсивно),
- Кастомные конвертеры (
TypeConverter).
Часть 8. Внедрение зависимостей (DI)
Встроенная реализация: возможности и ограничения
ASP.NET Core поставляется с минимальным, но достаточным DI-контейнером. Он поддерживает:
- Регистрацию сервисов с указанием времени жизни,
- Разрешение зависимостей через конструктор (constructor injection),
- Интеграцию с фреймворком (контроллеры, middleware, hosted services автоматически разрешаются из DI),
- Проверку циклических зависимостей на этапе сборки (при использовании
ValidateScopes).
Ограничения встроенного контейнера (по сравнению с Autofac, Lamar, Ninject):
- Нет поддержки регистрации по соглашению (convention-based registration),
- Нет декораторов, интерсепторов, property injection,
- Нет поддержки
IEnumerable<T>с разным временем жизни (все элементы будут иметь время жизни самогоIEnumerable), - Нет поддержки keyed/named dependencies.
Эти ограничения намеренны: они поощряют простые, тестируемые архитектуры. Для сложных сценариев можно подключить сторонний контейнер — фреймворк предоставляет стандартный интерфейс IServiceProviderFactory<T>.
Время жизни сервисов: не определения, а последствия
Выбор времени жизни — это архитектурное решение, влияющее на производительность, изоляцию и потокобезопасность.
-
Singleton
- Один экземпляр на всё приложение.
- Когда использовать: логгеры, утилиты без состояния, кэши-обёртки, глобальные конфигурации.
- Опасности:
— Хранение состояния (например,List<T> _items) приведёт к гонкам данных,
— Зависимость от scoped-сервисов (например,DbContext) вызоветInvalidOperationExceptionпри попытке разрешения.
-
Scoped
- Один экземпляр на HTTP-запрос (или на сессию в фоновой задаче).
- Когда использовать:
—DbContext(EF Core требует одного контекста на запрос для отслеживания изменений),
— Сервисы с состоянием, привязанным к запросу (например,ICurrentUserService),
— Unit of Work, репозитории в рамках одного запроса. - Опасности:
— Использование в singleton’ах (см. выше),
— Передача scoped-сервиса в фоновый поток без создания scope’а.
-
Transient
- Новый экземпляр при каждом разрешении.
- Когда использовать:
— Простые, stateless-сервисы (валидаторы, мапперы, фабрики),
— Сервисы, которые создают scoped-зависимости внутри себя (например,IServiceScopeFactory.CreateScope()). - Опасности:
— Создание «тяжёлых» объектов (например, подключения к БД) — приведёт к утечкам ресурсов,
— Неявное создание множества экземпляров при вложенных разрешениях.
Важное уточнение: время жизни определяет жизненный цикл экземпляра, а не время жизни ссылки. DI-контейнер управляет временем жизни только для объектов, созданных им самим. Если вы создаёте экземпляр вручную (new MyService()), контейнер не отслеживает его.
DI в различных контекстах
-
В контроллерах и Razor Pages
Зависимости инъектируются через конструктор. Фреймворк автоматически разрешает их из DI:public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
} -
В middleware
Middleware создаётся один раз при старте приложения (не на каждый запрос!), поэтому зависимости нельзя инъектировать в конструктор — они будут «заморожены» как singleton’ы.
Правильный способ — получать scoped-сервисы черезcontext.RequestServices:public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next) // только transient/singleton
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var logger = context.RequestServices.GetRequiredService<ILogger<LoggingMiddleware>>();
logger.LogInformation("Request started");
await _next(context);
}
} -
В фоновых задачах (IHostedService)
IHostedServiceсоздаётся как singleton. Для выполнения scoped-операций нужно вручную создавать scope:public class DataCleanupService : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
public DataCleanupService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.CleanupOldDataAsync(ct);
}
} -
В SignalR hubs
Хабы создаются на каждое соединение (de facto scoped). Зависимости инъектируются через конструктор, как в контроллерах.
Интеграция сторонних DI-контейнеров
Для подключения Autofac, Lamar и др. используется IServiceProviderFactory<T>:
var builder = WebApplication.CreateBuilder(args);
// Отключаем валидацию в built-in DI (она не нужна при использовании стороннего контейнера)
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
// Регистрация в Autofac
builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
containerBuilder.RegisterModule<MyAutofacModule>();
});
Контейнер создаётся после регистрации всех сервисов через builder.Services, поэтому:
- Сначала регистрируем через
IServiceCollection(стандартный путь), - Затем донастраиваем в
ConfigureContainer(специфичные фичи контейнера).
Это обеспечивает совместимость с библиотеками, которые регистрируют зависимости через IServiceCollection (например, EF Core, Identity).
Практические рекомендации
-
Избегайте Service Locator (
IServiceProvider.GetService<T>()в бизнес-логике). Это скрывает зависимости и усложняет тестирование. Используйте только в middleware и hosted services, где constructor injection невозможен. -
Не смешивайте времена жизни без необходимости. Если сервис A (scoped) зависит от B (singleton), это допустимо. Но если B (singleton) зависит от A (scoped) — будет исключение.
-
Тестируйте разрешение зависимостей. Используйте
Host.CreateDefaultBuilder().Build().Servicesв интеграционных тестах, чтобы убедиться, что все зависимости разрешаются. -
Регистрируйте интерфейсы, а не конкретные классы — это упрощает подмену реализаций (например, для моков в тестах).
Часть 9. Работа с данными в веб-приложении
Слои данных и их границы
В правильно спроектированном веб-приложении данные проходят через несколько слоёв, каждый из которых имеет чёткую ответственность:
-
Transport Layer (HTTP)
— Входящие данные: query string, route parameters, form fields, JSON/XML тело запроса,
— Исходящие данные: JSON, XML, HTML, файлы.
Ответственность: сериализация/десериализация, валидация формата. -
Application Layer (контроллеры, страницы)
— Принимает транспортные объекты (DTO),
— Оркестрирует вызовы сервисов,
— Преобразует результаты в транспортные объекты.
Ответственность: координация, не бизнес-логика. -
Domain Layer (сервисы, агрегаты)
— Содержит бизнес-правила, валидацию предметной области, инварианты,
— Оперирует доменными моделями (rich domain objects),
— Не знает о HTTP, DTO, ORM.
Ответственность: «почему?» — почему операция разрешена/запрещена. -
Infrastructure Layer (репозитории, ORM, внешние API)
— Реализует доступ к данным,
— Преобразует доменные модели ↔ DTO хранилища,
— Обеспечивает транзакционность.
Ответственность: «как?» — как сохранить/загрузить данные.
Ключевые типы объектов и их назначение:
-
Domain Model — классы предметной области с поведением (например,
Order.AddLineItem(Product, int quantity)), инкапсулирующие бизнес-правила. Не должны содержать атрибутов сериализации или ORM. -
DTO (Data Transfer Object) — плоские, неизменяемые объекты для передачи данных между слоями (например,
CreateOrderRequest,OrderResponse). Используются в контроллерах и при вызове внешних сервисов. -
ViewModel — DTO, специфичные для представления (например,
ProductDetailsPageModel), содержащие данные, необходимые именно для отрисовки страницы (включая выпадающие списки, флаги видимости и т.д.). -
Entity (ORM Entity) — классы, отображаемые на таблицы БД (например,
OrderEntity). Содержат атрибуты ORM ([Key],[Column]) и могут отличаться от Domain Model (например, содержать технические поля вродеRowVersion).
Разделение этих типов предотвращает утечку абстракций: например, атрибуты валидации [Required] для UI не должны влиять на бизнес-правила в домене.
Model Binding: от HTTP к объекту
Model Binding — механизм автоматического заполнения параметров действия (action method) из источников HTTP-запроса. Он работает до вызова метода контроллера и является частью конвейера MVC.
Источники данных и приоритеты:
- Route Values (
{id}в шаблоне) — высший приоритет для параметров с совпадающими именами, - Query String (
?page=2&size=10), - Form Data (
application/x-www-form-urlencoded,multipart/form-data), - Request Body (
application/json,application/xml) — только для одного параметра (обычно сложного типа), - Files — через
IFormFile.
Управление привязкой через атрибуты:
[FromRoute],[FromQuery],[FromForm],[FromBody]— явное указание источника,[BindRequired]— параметр обязателен, иначеModelStateбудет invalid,[BindNever]— исключить параметр из привязки (защита от overposting),[ModelBinder(typeof(MyCustomBinder))]— кастомный биндер.
Кастомный Model Binder реализует IModelBinder и регистрируется глобально или через атрибут:
public class SlugBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue("slug").FirstValue;
if (value != null && SlugHelper.IsValid(value))
{
bindingContext.Result = ModelBindingResult.Success(new Slug(value));
}
return Task.CompletedTask;
}
}
Валидация: ModelState и политики
Валидация происходит после привязки модели и состоит из двух уровней:
-
Валидация формата (Model State Validation)
— Проверка типов ("abc"→int= ошибка),
— Проверка атрибутовDataAnnotations([Required],[StringLength]),
— Результат сохраняется вModelState.IsValid. -
Бизнес-валидация (Domain Validation)
— Проверка инвариантов в доменных сервисах (OrderService.CreateOrder()может вернутьValidationResult),
— Не зависит от HTTP-контекста.
Глобальные фильтры валидации:
Атрибут [ApiController] автоматически возвращает 400 при !ModelState.IsValid. Это можно настроить через:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.ToDictionary(
e => e.Key,
e => e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
);
return new BadRequestObjectResult(errors);
};
});
Для сложных сценариев (например, условная валидация) используется IValidatableObject:
public class CreateUserRequest : IValidatableObject
{
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Password != ConfirmPassword)
{
yield return new ValidationResult("Passwords do not match",
new[] { nameof(ConfirmPassword) });
}
}
}
ORM-стек
-
Entity Framework Core (EF Core) — полноценный ORM с поддержкой:
— Отслеживания изменений (change tracking),
— Ленивой загрузки (lazy loading, опционально),
— Миграций кода первой (code-first migrations),
— Поддержки отношений (one-to-many, many-to-many),
— Глобальных фильтров (soft delete),
— Owned Types (вложенные объекты как часть агрегата).
Когда использовать: приложения с богатой доменной моделью, где важна продуктивность разработки и поддержка сложных сценариев. -
Dapper — микро-ORM, выполняющий только маппинг результатов SQL-запросов в объекты.
— Высокая производительность (близка к ADO.NET),
— Полный контроль над SQL,
— Нет отслеживания изменений — нужно писатьUPDATEвручную.
Когда использовать: high-load read-операции, legacy-БД с неудобной схемой, микросервисы с простыми CRUD-операциями. -
ADO.NET — низкоуровневый доступ к БД через
SqlConnection,SqlCommand,SqlDataReader.
— Максимальная производительность и контроль,
— Требует ручной обработки соединений, параметров, маппинга.
Когда использовать: критически важные операции (bulk insert), интеграция с нестандартными СУБД, кастомные провайдеры.
Паттерны доступа к данным:
-
Repository Pattern
Абстрагирует доступ к данным за интерфейсом (IProductRepository), скрывая детали ORM. Позволяет легко подменять реализации (например, для тестов).public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<IEnumerable<Product>> ListAsync();
Task AddAsync(Product product);
} -
Unit of Work
Обеспечивает атомарность операций над несколькими репозиториями через общую транзакцию. В EF Core этоDbContext— он сам является Unit of Work:using var transaction = await _context.Database.BeginTransactionAsync();
try
{
await _productRepo.AddAsync(product);
await _orderRepo.AddAsync(order);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
} -
CQRS (Command Query Responsibility Segregation)
Разделение операций на:
— Commands (изменение состояния:CreateOrderCommand),
— Queries (чтение:GetOrderQuery).
Позволяет оптимизировать каждый путь отдельно (например, использовать разные БД для записи и чтения).
Безопасность при работе с данными
- Параметризованные запросы — обязательны для предотвращения SQL-инъекций. EF Core и Dapper делают это автоматически при использовании
ExecuteAsync(sql, param). - Ограничение overposting — использование
[BindNever],BindPropertyв Razor Pages, DTO вместо domain models в контроллерах. - Пагинация — никогда не возвращать
IQueryable<T>из сервисов; всегда применятьSkip/Takeна уровне репозитория. - Фильтрация на уровне БД — избегать
.ToList().Where(...)для больших таблиц.